using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Toolkit.Framework;
using StardewModdingAPI.Toolkit.Framework.ModBlacklistData;
using StardewModdingAPI.Toolkit.Framework.ModData;
using StardewModdingAPI.Toolkit.Framework.ModScanning;
using StardewModdingAPI.Toolkit.Framework.UpdateData;
using StardewModdingAPI.Toolkit.Serialization.Models;
using StardewModdingAPI.Toolkit.Utilities.PathLookups;

namespace StardewModdingAPI.Framework.ModLoading;

/// <summary>Finds and processes mod metadata.</summary>
internal class ModResolver
{
    /*********
    ** Public methods
    *********/
    /// <summary>Get manifest metadata for each folder in the given root path.</summary>
    /// <param name="toolkit">The mod toolkit.</param>
    /// <param name="rootPath">The root path to search for mods.</param>
    /// <param name="modBlacklist">The malicious mods which should be blocked by SMAPI.</param>
    /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param>
    /// <param name="useCaseInsensitiveFilePaths">Whether to match file paths case-insensitively, even on Linux.</param>
    /// <returns>Returns the manifests by relative folder.</returns>
    public IEnumerable<IModMetadata> ReadManifests(ModToolkit toolkit, string rootPath, ModBlacklist modBlacklist, ModDatabase modDatabase, bool useCaseInsensitiveFilePaths)
    {
        foreach (ModFolder folder in toolkit.GetModFolders(rootPath, useCaseInsensitiveFilePaths))
        {
            Manifest? manifest = folder.Manifest;

            // check blacklist
            ModBlacklistEntryModel? blacklistEntry = null;
            if (manifest?.UniqueID != null)
            {
                string? entryDllPath = !string.IsNullOrWhiteSpace(manifest.EntryDll)
                    ? Path.Combine(folder.DirectoryPath, manifest.EntryDll)
                    : null;
                if (!File.Exists(entryDllPath))
                    entryDllPath = null;

                blacklistEntry = modBlacklist.Get(manifest.UniqueID, entryDllPath);
            }

            // parse internal data record (if any)
            ModDataRecordVersionedFields? dataRecord = modDatabase.Get(manifest?.UniqueID)?.GetVersionedFields(manifest);

            // apply defaults
            if (manifest != null && dataRecord?.UpdateKey is not null)
                manifest.OverrideUpdateKeys(dataRecord.UpdateKey);

            // build metadata
            bool shouldIgnore = folder.Type == ModType.Ignored;
            IModMetadata metadata = new ModMetadata(folder.DisplayName, folder.Directory.FullName, rootPath, manifest, dataRecord, isIgnored: shouldIgnore);
            if (blacklistEntry != null)
                metadata.SetStatus(ModMetadataStatus.Failed, ModFailReason.Malicious, blacklistEntry.Message);
            else if (shouldIgnore)
                metadata.SetStatus(ModMetadataStatus.Failed, ModFailReason.DisabledByDotConvention, "disabled by dot convention");
            else if (folder.ManifestParseError != ModParseError.None)
            {
                ModFailReason reason = folder.ManifestParseError switch
                {
                    ModParseError.EmptyFolder or ModParseError.EmptyVortexFolder => ModFailReason.EmptyFolder,
                    ModParseError.XnbMod => ModFailReason.XnbMod,
                    _ => ModFailReason.InvalidManifest
                };

                metadata.SetStatus(ModMetadataStatus.Failed, reason, folder.ManifestParseErrorText);
            }

            yield return metadata;
        }
    }

    /// <summary>Validate manifest metadata.</summary>
    /// <param name="mods">The mod manifests to validate.</param>
    /// <param name="apiVersion">The current SMAPI version.</param>
    /// <param name="gameVersion">The current Stardew Valley version.</param>
    /// <param name="getUpdateUrl">Get an update URL for an update key (if valid).</param>
    /// <param name="getFileLookup">Get a file lookup for the given directory.</param>
    /// <param name="validateFilesExist">Whether to validate that files referenced in the manifest (like <see cref="IManifest.EntryDll"/>) exist on disk. This can be disabled to only validate the manifest itself.</param>
    [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "Manifest values may be null before they're validated.")]
    [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "Manifest values may be null before they're validated.")]
    public void ValidateManifests(IEnumerable<IModMetadata> mods, ISemanticVersion apiVersion, ISemanticVersion gameVersion, Func<string, string?> getUpdateUrl, Func<string, IFileLookup> getFileLookup, bool validateFilesExist = true)
    {
        mods = mods.ToArray();

        // validate each manifest
        foreach (IModMetadata mod in mods)
        {
            // skip if already failed
            if (mod.Status == ModMetadataStatus.Failed)
                continue;

            // validate compatibility from internal data
            switch (mod.DataRecord?.Status)
            {
                case ModStatus.Obsolete:
                    mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Obsolete, $"it's obsolete: {mod.DataRecord.StatusReasonPhrase}", this.GetTechnicalReasonForStatusOverride(mod));
                    continue;

                case ModStatus.AssumeBroken:
                    {
                        // get reason
                        string reasonPhrase = mod.DataRecord.StatusReasonPhrase ?? "it's no longer compatible";

                        // get update URLs
                        List<string> updateUrls = [];
                        foreach (UpdateKey key in mod.GetUpdateKeys(validOnly: true))
                        {
                            string? url = getUpdateUrl(key.ToString());
                            if (url != null)
                                updateUrls.Add(url);
                        }

                        // default update URL
                        updateUrls.Add("https://smapi.io/mods");

                        // build error
                        string error = $"{reasonPhrase}. Please check for a ";
                        if (mod.DataRecord.StatusUpperVersion == null || mod.Manifest.Version?.Equals(mod.DataRecord.StatusUpperVersion) == true)
                            error += "newer version";
                        else
                            error += $"version newer than {mod.DataRecord.StatusUpperVersion}";
                        error += " at " + string.Join(" or ", updateUrls);

                        mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Incompatible, error, this.GetTechnicalReasonForStatusOverride(mod));
                    }
                    continue;
            }

            // validate SMAPI version
            if (mod.Manifest.MinimumApiVersion?.IsNewerThan(apiVersion) == true)
            {
                mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Incompatible, $"it needs SMAPI {mod.Manifest.MinimumApiVersion} or later. Please update SMAPI to the latest version to use this mod.");
                continue;
            }

            // validate game version
            if (mod.Manifest.MinimumGameVersion?.IsNewerThan(gameVersion) == true)
            {
                mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Incompatible, $"it needs Stardew Valley {mod.Manifest.MinimumGameVersion} or later. Please update your game to the latest version to use this mod.");
                continue;
            }

            // validate manifest format
            if (!ManifestValidator.TryValidateFields(mod.Manifest, out string manifestError))
            {
                mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its {manifestError}");
                continue;
            }

            // check that DLL exists if applicable
            if (!string.IsNullOrEmpty(mod.Manifest.EntryDll) && validateFilesExist)
            {
                IFileLookup pathLookup = getFileLookup(mod.DirectoryPath);
                FileInfo file = pathLookup.GetFile(mod.Manifest.EntryDll!);
                if (!file.Exists)
                {
                    mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist.");
                    continue;
                }
            }
        }

        // validate IDs are unique
        {
            var duplicatesById = mods
                .GroupBy(mod => mod.Manifest?.UniqueID?.Trim(), mod => mod, StringComparer.OrdinalIgnoreCase)
                .Where(p => !string.IsNullOrEmpty(p.Key) && p.Count() > 1);
            foreach (var group in duplicatesById)
            {
                foreach (IModMetadata mod in group)
                {
                    if (mod is { Status: ModMetadataStatus.Failed, FailReason: not (ModFailReason.InvalidManifest or ModFailReason.LoadFailed or ModFailReason.MissingDependencies) })
                        continue;

                    string folderList = string.Join(", ", group.Select(p => p.GetRelativePathWithRoot()).OrderBy(p => p));
                    mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, $"you have multiple copies of this mod installed. To fix this, delete these folders and reinstall the mod: {folderList}.");
                }
            }
        }
    }

    /// <summary>Apply preliminary overrides to the load order based on the SMAPI configuration.</summary>
    /// <param name="mods">The mods to process.</param>
    /// <param name="modIdsToLoadEarly">The mod IDs SMAPI should load before any other mods (except those needed to load them).</param>
    /// <param name="modIdsToLoadLate">The mod IDs SMAPI should load after any other mods.</param>
    public IModMetadata[] ApplyLoadOrderOverrides(IModMetadata[] mods, HashSet<string> modIdsToLoadEarly, HashSet<string> modIdsToLoadLate)
    {
        if (!modIdsToLoadEarly.Any() && !modIdsToLoadLate.Any())
            return mods;

        string[] earlyArray = modIdsToLoadEarly.ToArray();
        string[] lateArray = modIdsToLoadLate.ToArray();

        return mods
            .OrderBy(mod =>
            {
                string? id = mod.Manifest?.UniqueID;

                if (id is not null)
                {
                    if (modIdsToLoadEarly.TryGetValue(id, out string? actualId))
                        return -int.MaxValue + Array.IndexOf(earlyArray, actualId);

                    if (modIdsToLoadLate.TryGetValue(id, out actualId))
                        return int.MaxValue - Array.IndexOf(lateArray, actualId);
                }

                return 0;
            })
            .ToArray();
    }

    /// <summary>Sort the given mods by the order they should be loaded.</summary>
    /// <param name="mods">The mods to process.</param>
    /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param>
    public IEnumerable<IModMetadata> ProcessDependencies(IReadOnlyList<IModMetadata> mods, ModDatabase modDatabase)
    {
        // initialize metadata
        mods = mods.ToArray();
        var sortedMods = new Stack<IModMetadata>();
        var states = mods.ToDictionary(mod => mod, _ => ModDependencyStatus.Queued);

        // handle failed mods
        foreach (IModMetadata mod in mods.Where(m => m.Status == ModMetadataStatus.Failed))
        {
            states[mod] = ModDependencyStatus.Failed;
            sortedMods.Push(mod);
        }

        // sort mods
        foreach (IModMetadata mod in mods)
            this.ProcessDependencies(mods, modDatabase, mod, states, sortedMods, new List<IModMetadata>());

        return sortedMods.Reverse();
    }


    /*********
    ** Private methods
    *********/
    /// <summary>Sort a mod's dependencies by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies.</summary>
    /// <param name="mods">The full list of mods being validated.</param>
    /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param>
    /// <param name="mod">The mod whose dependencies to process.</param>
    /// <param name="states">The dependency state for each mod.</param>
    /// <param name="sortedMods">The list in which to save mods sorted by dependency order.</param>
    /// <param name="currentChain">The current change of mod dependencies.</param>
    /// <returns>Returns the mod dependency status.</returns>
    private ModDependencyStatus ProcessDependencies(IReadOnlyList<IModMetadata> mods, ModDatabase modDatabase, IModMetadata mod, IDictionary<IModMetadata, ModDependencyStatus> states, Stack<IModMetadata> sortedMods, ICollection<IModMetadata> currentChain)
    {
        // check if already visited
        switch (states[mod])
        {
            // already sorted or failed
            case ModDependencyStatus.Sorted:
            case ModDependencyStatus.Failed:
                return states[mod];

            // dependency loop
            case ModDependencyStatus.Checking:
                // This should never happen. The higher-level mod checks if the dependency is
                // already being checked, so it can fail without visiting a mod twice. If this
                // case is hit, that logic didn't catch the dependency loop for some reason.
                throw new InvalidModStateException($"A dependency loop was not caught by the calling iteration ({string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {mod.DisplayName})).");

            // not visited yet, start processing
            case ModDependencyStatus.Queued:
                break;

            // sanity check
            default:
                throw new InvalidModStateException($"Unknown dependency status '{states[mod]}'.");
        }

        // collect dependencies
        ModDependency[] dependencies = this.GetDependenciesFrom(mod.Manifest, mods).ToArray();

        // mark sorted if no dependencies
        if (!dependencies.Any())
        {
            sortedMods.Push(mod);
            return states[mod] = ModDependencyStatus.Sorted;
        }

        // mark failed if missing dependencies
        {
            string[] failedModNames = (
                from entry in dependencies
                where entry is { IsRequired: true, Mod: null }

                let dataEntry = modDatabase.Get(entry.ID)
                where dataEntry?.IgnoreDependencies is not true

                let displayName = dataEntry?.DisplayName ?? entry.ID
                let modUrl = modDatabase.GetModPageUrlFor(entry.ID)

                orderby displayName
                select modUrl != null
                    ? $"{displayName}: {modUrl}"
                    : displayName
            ).ToArray();
            if (failedModNames.Any())
            {
                sortedMods.Push(mod);
                mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"it requires mods which aren't installed ({string.Join(", ", failedModNames)}).");
                return states[mod] = ModDependencyStatus.Failed;
            }
        }

        // dependency min version not met, mark failed
        {
            string[] failedLabels =
                (
                    from entry in dependencies
                    where
                        entry is { Mod: not null, MinVersion: not null }
                        && entry.MinVersion.IsNewerThan(entry.Mod.Manifest.Version)
                    select $"{entry.Mod!.DisplayName} (needs {entry.MinVersion} or later)"
                )
                .ToArray();
            if (failedLabels.Any())
            {
                sortedMods.Push(mod);
                mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"it needs newer versions of some mods: {string.Join(", ", failedLabels)}.");
                return states[mod] = ModDependencyStatus.Failed;
            }
        }

        // process dependencies
        {
            states[mod] = ModDependencyStatus.Checking;

            // recursively sort dependencies
            foreach (ModDependency dependency in dependencies)
            {
                IModMetadata? requiredMod = dependency.Mod;
                if (requiredMod == null)
                    continue; // missing dependencies are handled earlier

                // detect dependency loop
                var subchain = new List<IModMetadata>(currentChain) { mod };
                if (states[requiredMod] == ModDependencyStatus.Checking)
                {
                    sortedMods.Push(mod);
                    mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"its dependencies have a circular reference: {string.Join(" => ", subchain.Select(p => p.DisplayName))} => {requiredMod.DisplayName}).");
                    return states[mod] = ModDependencyStatus.Failed;
                }

                // recursively process each dependency
                var subStatus = this.ProcessDependencies(mods, modDatabase, requiredMod, states, sortedMods, subchain);
                switch (subStatus)
                {
                    // sorted successfully
                    case ModDependencyStatus.Sorted:
                    case ModDependencyStatus.Failed when !dependency.IsRequired: // ignore failed optional dependency
                    case ModDependencyStatus.Failed when modDatabase.Get(dependency.ID)?.IgnoreDependencies is true: // ignore failed dependency based on SMAPI metadata
                        break;

                    // failed, which means this mod can't be loaded either
                    case ModDependencyStatus.Failed:
                        sortedMods.Push(mod);
                        mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"it needs the '{requiredMod.DisplayName}' mod, which couldn't be loaded.");
                        return states[mod] = ModDependencyStatus.Failed;

                    // unexpected status
                    case ModDependencyStatus.Queued:
                    case ModDependencyStatus.Checking:
                        throw new InvalidModStateException($"Something went wrong sorting dependencies: mod '{requiredMod.DisplayName}' unexpectedly stayed in the '{subStatus}' status.");

                    // sanity check
                    default:
                        throw new InvalidModStateException($"Unknown dependency status '{states[mod]}'.");
                }
            }

            // all requirements sorted successfully
            sortedMods.Push(mod);
            return states[mod] = ModDependencyStatus.Sorted;
        }
    }

    /// <summary>Get the dependencies declared in a manifest.</summary>
    /// <param name="manifest">The mod manifest.</param>
    /// <param name="loadedMods">The loaded mods.</param>
    private IEnumerable<ModDependency> GetDependenciesFrom(IManifest manifest, IReadOnlyList<IModMetadata> loadedMods)
    {
        IModMetadata? FindMod(string id) => loadedMods.FirstOrDefault(m => m.HasId(id));

        // yield dependencies
        foreach (IManifestDependency entry in manifest.Dependencies)
            yield return new ModDependency(entry.UniqueID, entry.MinimumVersion, FindMod(entry.UniqueID), entry.IsRequired);

        // yield content pack parent
        if (manifest.ContentPackFor != null)
            yield return new ModDependency(manifest.ContentPackFor.UniqueID, manifest.ContentPackFor.MinimumVersion, FindMod(manifest.ContentPackFor.UniqueID), isRequired: true);
    }

    /// <summary>Get a technical message indicating why a mod's compatibility status was overridden, if applicable.</summary>
    /// <param name="mod">The mod metadata.</param>
    private string? GetTechnicalReasonForStatusOverride(IModMetadata mod)
    {
        // get compatibility list record
        ModDataRecordVersionedFields? data = mod.DataRecord;
        if (data == null)
            return null;

        // get status label
        string statusLabel = data.Status switch
        {
            ModStatus.AssumeBroken => "'assume broken'",
            ModStatus.AssumeCompatible => "'assume compatible'",
            ModStatus.Obsolete => "obsolete",
            _ => data.Status.ToString()
        };

        // get reason
        string?[] reasons = new[] { data.StatusReasonPhrase, data.StatusReasonDetails }
            .Where(p => !string.IsNullOrWhiteSpace(p))
            .ToArray();

        // build message
        return
            $"marked {statusLabel} in SMAPI's internal compatibility list for "
            + (data.StatusUpperVersion != null ? $"versions up to {data.StatusUpperVersion}" : "all versions")
            + ": "
            + (reasons.Any() ? string.Join(": ", reasons) : "no reason given")
            + ".";
    }


    /*********
    ** Private models
    *********/
    /// <summary>Represents a dependency from one mod to another.</summary>
    private readonly struct ModDependency
    {
        /*********
        ** Accessors
        *********/
        /// <summary>The unique ID of the required mod.</summary>
        public string ID { get; }

        /// <summary>The minimum required version (if any).</summary>
        public ISemanticVersion? MinVersion { get; }

        /// <summary>Whether the mod shouldn't be loaded if the dependency isn't available.</summary>
        public bool IsRequired { get; }

        /// <summary>The loaded mod that fulfills the dependency (if available).</summary>
        public IModMetadata? Mod { get; }


        /*********
        ** Public methods
        *********/
        /// <summary>Construct an instance.</summary>
        /// <param name="id">The unique ID of the required mod.</param>
        /// <param name="minVersion">The minimum required version (if any).</param>
        /// <param name="mod">The loaded mod that fulfills the dependency (if available).</param>
        /// <param name="isRequired">Whether the mod shouldn't be loaded if the dependency isn't available.</param>
        public ModDependency(string id, ISemanticVersion? minVersion, IModMetadata? mod, bool isRequired)
        {
            this.ID = id;
            this.MinVersion = minVersion;
            this.Mod = mod;
            this.IsRequired = isRequired;
        }
    }
}
